Skip to content

Adding Tag Extraction feature from student reviews#52

Open
Akshats-git wants to merge 4 commits intoOpenLake:mainfrom
Akshats-git:feature/tag-extraction
Open

Adding Tag Extraction feature from student reviews#52
Akshats-git wants to merge 4 commits intoOpenLake:mainfrom
Akshats-git:feature/tag-extraction

Conversation

@Akshats-git
Copy link

@Akshats-git Akshats-git commented Jan 29, 2026

Added an AI-driven feature that analyzes course reviews to automatically extract and display 6-8 key themes (e.g., "Heavy Workload", "Engaging Lectures", "Tough Exams") as color-coded badges in the course page sidebar. The feature uses Gemini AI to identify common topics with sentiment analysis (positive/negative/neutral) and frequency counts, helping students quickly understand course characteristics at a glance.

Fixes #43

Summary by CodeRabbit

Release Notes

  • New Features

    • Course comparison: compare up to 4 courses side-by-side with metrics, charts, and review highlights
    • AI-generated course summaries and course key themes powered by an AI service
    • Inline compare actions and a new "Compare" nav item for quick access
  • Documentation

    • Expanded setup and sentiment analysis guides; new quick reference and implementation docs
    • Updated contribution/setup docs to include GEMINI_API_KEY example
  • Tests & Tools

    • Added sentiment analysis test suite and utility scripts for local setup and DB checks

Copilot AI review requested due to automatic review settings January 29, 2026 16:31
@coderabbitai
Copy link

coderabbitai bot commented Jan 29, 2026

Walkthrough

Adds Gemini API configuration and multiple AI features: two new Next.js API routes for theme extraction and summary generation, client UI for course comparison (page + selector + charts + table + highlights), course-detail AI widgets (summary, key themes, add-to-comparison), sentiment analysis backend (routes, migrations, utils, types, scripts), and docs/setup updates.

Changes

Cohort / File(s) Summary
Env & Contribution Docs
\.env\.example, CONTRIBUTION\.md
Adds GEMINI_API_KEY to examples and documents its purpose and exposure guidance.
High-level Docs
docs/COURSE_COMPARISON_FEATURE\.md, docs/SENTIMENT_*.md, docs/SETUP_*.md, docs/WEEK1_CHECKLIST\.md
New/expanded design and implementation docs for course comparison and sentiment analysis (design, backend implementation, quick reference, setup, checklists).
API: AI Features
src/app/api/extract-themes/route.ts, src/app/api/generate-summary/route.ts
New POST endpoints that query Supabase, validate input, call Gemini, parse responses, and return themes/summaries with error handling.
API: Sentiment Backend
src/app/api/analyze-sentiment/route.ts, src/app/api/batch-analyze-sentiment/route.ts
New sentiment analysis endpoints (POST/GET, batch) integrating Gemini, with retry/parsing logic and DB persistence/upsert.
Migrations & DB
setup_database\.sql, src/migrations/migration\.sql, src/migrations/sentiment_analysis\.sql
Large DB migration adding sentiment schema (review_sentiments), sentiment columns on courses/professors, views, triggers, functions, index and RLS notes; one small index name fix.
Sentiment Types & Config
src/types/sentiment\.ts, src/lib/sentiment-config\.ts
Adds typed sentiment models, helper functions, and centralized SENTIMENT_CONFIG exports.
Sentiment Utilities & Scripts
src/lib/sentiment-utils\.ts, src/lib/add-dummy-reviews\.ts, src/lib/check-db\.ts, src/lib/verify-reviews\.ts, setup\.sh
Client/server helpers for triggering/ querying sentiment, batch helpers, DB-check and seeding scripts, and a setup shell script.
Course Comparison UI
src/app/courses/compare/page\.tsx, src/components/courses/compare/*
New compare page and components: selector, ComparisonTable, ComparisonCharts (Recharts), ReviewHighlights; client-side selection, charts, and highlights.
Comparison Integration & Buttons
src/components/courses/CompareButton\.tsx, src/components/courses/CompareCoursesButton\.tsx, src/components/courses/course_page/AddToComparison\.tsx
LocalStorage-backed comparison list management, floating panel, compare buttons, and per-course add/remove UI with custom event sync.
Course Detail & Cards
src/app/courses/\[courseId\]/page\.tsx, src/components/courses/course_page/CourseSummary\.tsx, src/components/courses/course_page/CourseKeyThemes\.tsx, src/components/courses/ItemCard\.tsx, src/components/layout/Navbar\.tsx
Embeds CourseSummary and CourseKeyThemes on course page, AddToComparison in sidebar, adds CompareButton to ItemCard footer, and adds "Compare" nav link.
Ratings Integration
src/pages/api/ratings/route\.ts, src/components/courses/AddReviewButton\.tsx, src/components/professors/AddReviewButtonProfessor\.tsx
Triggers background sentiment analysis when comments are updated/long enough; non-blocking fire-and-forget calls.
Tests
src/lib/__tests__/sentiment-analysis.test\.ts
New comprehensive test suite for sentiment analysis flows and utilities.
New UI Components (misc)
src/components/courses/...
Multiple new components and UI adjustments supporting comparison and AI features (various files under components).

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Browser
    participant NextServer as Next.js API
    participant Gemini as Gemini API
    participant Supabase as DB

    User->>Browser: Open course page / requests summary
    Browser->>NextServer: POST /api/generate-summary { courseId, courseCode, courseTitle }
    NextServer->>Supabase: Query up to 50 reviews for courseId
    Supabase-->>NextServer: Return reviews
    NextServer->>Gemini: Send compiled prompt + reviews
    Gemini-->>NextServer: Generated summary JSON/text
    NextServer-->>Browser: Summary + reviewCount
    Browser->>User: Render CourseSummary component
Loading
sequenceDiagram
    participant User
    participant Browser
    participant NextServer as Next.js API
    participant Gemini as Gemini API
    participant Supabase as DB

    User->>Browser: Request course key themes
    Browser->>NextServer: POST /api/extract-themes { courseId, courseCode }
    NextServer->>Supabase: Query up to 100 comments for course
    Supabase-->>NextServer: Return comments
    NextServer->>Gemini: Analyze comments -> extract themes (6-8)
    Gemini-->>NextServer: Themes JSON
    NextServer-->>Browser: Themes + reviewCount
    Browser->>User: Render CourseKeyThemes badges
Loading
sequenceDiagram
    participant User
    participant Browser
    participant localStorage
    participant Page as Compare Page
    participant Supabase as DB
    participant Router as Next Router

    User->>Browser: Click "Add to comparison" on a course
    Browser->>localStorage: Update `course_comparison_list`
    localStorage-->>Browser: Dispatch 'comparison-list-updated' event
    Browser->>Browser: UI updates (buttons, floating panel)
    User->>Browser: Click "Compare" (floating panel)
    Browser->>Router: Navigate /courses/compare?courses=id1,id2,...
    Router->>Page: Load compare page
    Page->>Supabase: Fetch course details for selected IDs
    Supabase-->>Page: Course data
    Page->>Browser: Render ComparisonTable, ComparisonCharts, ReviewHighlights
    Browser->>User: Display side-by-side comparison
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • fix review #39 — modifies src/components/courses/AddReviewButton.tsx; related because both introduce background sentiment triggers after review updates.

Poem

🐇 I dug through reviews both short and long,

Gems and themes hum like a spring-time song.
Four courses lined up, charts bright and true,
Gemini whispers what students construe.
Hop, click, compare — a rabbit’s review.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes substantial out-of-scope additions beyond tag extraction: complete sentiment analysis system (analyze-sentiment, batch-analyze-sentiment routes), course comparison feature (compare page, charts, table, selector), comprehensive documentation, and database schema changes unrelated to issue #43's tag extraction requirement. Separate sentiment analysis backend infrastructure, comparison feature components, and comprehensive database migrations into dedicated PRs. Keep this PR focused on tag extraction: CourseKeyThemes, extract-themes API, and minimal related changes only.
Docstring Coverage ⚠️ Warning Docstring coverage is 55.10% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Adding Tag Extraction feature from student reviews' accurately describes the primary feature addition. It is clear, specific, and directly related to the main changeset objective of implementing theme/tag extraction from reviews using AI.
Linked Issues check ✅ Passed The pull request implements the core objective from issue #43: extracting key themes from student reviews (via CourseKeyThemes component calling /api/extract-themes) and surfacing them as color-coded sentiment badges, enabling quick course overview.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

🤖 Fix all issues with AI agents
In `@docs/COURSE_COMPARISON_FEATURE.md`:
- Around line 148-150: The fenced code block showing the example request
"/courses/compare?courses=MAL401,MAL402" is missing a language specifier; update
that block to include a language identifier (e.g., "text" or "http") so the
snippet is syntax-highlighted and accessible—locate the fenced block containing
"/courses/compare?courses=MAL401,MAL402" and change the opening backticks to
include the chosen language (for example, ```text).

In `@src/app/api/extract-themes/route.ts`:
- Around line 52-102: The Gemini fetch call (creating geminiResponse) has no
timeout; wrap the request with an AbortController, pass controller.signal into
the fetch options for the gemini request, start a timeout (e.g., setTimeout to
call controller.abort() after a chosen ms) and clearTimeout on success, and
update the route handler's catch block to detect an AbortError (error.name ===
'AbortError') and return a 504 NextResponse.json({ error: 'Theme extraction
timed out' }); ensure you reference the existing geminiResponse fetch invocation
and clear the timeout to avoid leaks.
- Around line 52-53: The fetch call that creates geminiResponse currently sends
the API key in the URL query string; change the request to call the same
endpoint without the ?key=... suffix and pass the API key via the
"x-goog-api-key" header instead. Update the geminiResponse fetch invocation to
include an options object (method, headers, body) and add "x-goog-api-key":
geminiApiKey to headers (keep other headers and the JSON body intact). Ensure
the request URL is
"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent"
and remove any query-parameter usage of geminiApiKey.

In `@src/app/api/generate-summary/route.ts`:
- Around line 73-89: The prompt embeds raw user reviews via the reviewsText
interpolation in the prompt (see reviewsText in
src/app/api/generate-summary/route.ts), which allows prompt-injection; fix by
sanitizing/escaping or structuring the input: sanitize each review string
(remove/escape instruction-like tokens such as "ignore", "system", "assistant",
or long-form prompts), or better, pass reviews as structured data to the model
(e.g., include reviews as a JSON payload or as a separate non-instruction
message) and send fixed system/instruction messages that never include raw user
content; update the code that builds the prompt to use the sanitized/structured
reviews and keep the template literal (the instruction block) static so user
text cannot inject new instructions.
- Around line 4-13: The POST handler destructures courseCode and courseTitle but
does not validate them, causing "undefined" to appear in the Gemini prompt;
update the POST function to either validate and return a 400 when courseCode or
courseTitle are missing (similar to the existing courseId check) or assign safe
defaults (e.g., empty string or "Unknown") before using them in the prompt;
modify the destructuring/validation in POST and ensure any later use of
courseCode/courseTitle (in the Gemini prompt construction) uses the validated or
defaulted values.
- Around line 61-102: The fetch call that creates geminiResponse lacks a timeout
and may hang; wrap the request in an AbortController with a timer (e.g., create
AbortController(), set a setTimeout to call controller.abort() after your
desired timeout) and pass controller.signal into the fetch options for the
request that builds the Gemini payload, clear the timeout after fetch completes,
and handle the abort/timeout case (detect AbortError or signal.aborted) in the
surrounding handler that constructs the response so you return a proper error
instead of hanging; modify the code where geminiResponse is awaited to include
the controller, timeout, and cleanup.
- Around line 61-62: The fetch call that creates geminiResponse currently
appends geminiApiKey as a query parameter; remove the key from the URL and move
authentication into the request headers by adding 'x-goog-api-key': geminiApiKey
and 'Content-Type': 'application/json', and ensure the fetch options include
method: 'POST' and the JSON body used by generateContent; update the call site
that builds the request body so it passes JSON.stringify(...) in the fetch
options instead of relying on URL params (refer to geminiResponse, geminiApiKey
and the fetch invocation in route.ts).

In `@src/app/courses/compare/page.tsx`:
- Around line 20-31: The component currently calls useSearchParams() in a
statically rendered page which will fail in production; wrap the subtree that
uses useSearchParams (e.g., the CourseComparisonPage or the component exported
from page.tsx) in a React Suspense boundary with a fallback (import Suspense
from 'react') so the CSR bailout is isolated, or alternatively stop calling
useSearchParams() here and accept searchParams as a prop from a parent Server
Component; update the default export (Page) or the parent layout to render
<Suspense fallback={...}> around the component that reads search params.

In `@src/components/courses/compare/ComparisonCharts.tsx`:
- Around line 69-77: The radarData is built per course but the RadarChart
expects data per metric; change the shape so each element represents a metric
(e.g., { metric: 'Overall', courseA: 4.5, courseB: 3.8, ... }) by iterating
metrics ['overall','difficulty','workload','reviews'] and mapping each course's
value into keys named by course.code (use Math.min(course.review_count/10,5) for
reviews); then update the RadarChart rendering to generate one <Radar> per
course (use course.code as dataKey and colors[idx % colors.length] for
fill/stroke) so each radar trace reads its values from the metric-shaped
radarData and the chart displays one trace per course. Ensure references to
radarData, courses, colors, and the RadarChart/Radar components (the existing
Radar rendering block) are updated accordingly.

In `@src/components/courses/compare/ComparisonTable.tsx`:
- Around line 63-73: The renderers in ComparisonTable.tsx call toFixed on
numeric fields (e.g., course.overall_rating, course.difficulty_rating,
course.workload_rating) which will throw if the value is null/undefined; update
each renderer to defensively check the value before calling toFixed (or coerce
to a safe default number) and render a fallback (e.g., '-' or 'N/A') when
missing; ensure you also pass a safe numeric value into StarRating (or handle
its props) so it never receives undefined—refer to the render lambda that
returns the <div> with StarRating and the span showing
course.overall_rating.toFixed(1) (and the equivalent renderers for
difficulty_rating and workload_rating) to apply the checks.

In `@src/components/courses/compare/CourseSelector.tsx`:
- Around line 130-134: CourseSelector currently calls
course.overall_rating.toFixed(1) which can throw if overall_rating is
null/undefined; update the display logic in the CourseSelector component to
defensively handle missing ratings by normalizing the value (e.g., use a default
like 0 or fallback text) before calling toFixed, e.g., check
course.overall_rating != null or coalesce with 0 (refer to course.overall_rating
in the JSX) so the UI never invokes toFixed on undefined/null.

In `@src/components/courses/compare/ReviewHighlights.tsx`:
- Around line 70-72: The reviews fetch branch currently swallows errors; modify
the block handling the reviews fetch (the conditional around
reviewsByUUID[course.id] = data) to log the error when the fetch fails using the
same logger/mechanism used for the UUID fetch error, and include course.id (or
course identifier) in the log so failures for a specific course are traceable;
keep the existing behavior of assigning data when no error.

In `@src/components/courses/CompareButton.tsx`:
- Around line 25-29: getComparisonList currently calls JSON.parse on
localStorage data which can throw on corrupted JSON; wrap the parse in a
try-catch inside getComparisonList (checking typeof window first remains), and
on parse failure return an empty array (optionally remove the bad STORAGE_KEY or
log the error) so the component doesn't crash when stored data is invalid.

In `@src/components/courses/CompareCoursesButton.tsx`:
- Around line 11-15: getComparisonList currently calls JSON.parse on
localStorage.getItem(STORAGE_KEY) without handling malformed JSON; wrap the
parse in a try-catch inside getComparisonList so that if JSON.parse throws you
return an empty array (and optionally clear the corrupted STORAGE_KEY or log the
error), ensuring the component won't break when stored data is invalid.

In `@src/components/courses/course_page/AddToComparison.tsx`:
- Around line 22-25: Normalize and dedupe the comparison list inside
setComparisonList by removing duplicate course IDs before writing to
localStorage and dispatching the update event; update the implementation in
setComparisonList to create a deduplicated array (preserving order) from the
incoming courseIds, JSON.stringify and store that deduped array, then dispatch
'comparison-list-updated'. Also apply the same normalization wherever the
comparison is updated/created (the related update handlers around lines 45-52)
so stored lists never contain duplicates and the 4-item cap behaves correctly.
- Around line 16-20: getComparisonList currently calls JSON.parse on
localStorage data which can throw or return non-array values; wrap the parse in
a try/catch, validate the parsed value is an array of strings, and return [] on
error or invalid shape. In the getComparisonList function, read
localStorage.getItem(STORAGE_KEY) then try JSON.parse inside try/catch, check
Array.isArray(result) and that every item is typeof 'string' (or coerce/ignore
non-strings), and optionally remove the malformed STORAGE_KEY entry before
returning [].

In `@src/components/courses/course_page/CourseSummary.tsx`:
- Around line 22-24: In generateSummary, the loading state is incorrectly set to
false at the start; change the initial call to setIsLoading(true) (leave
setError(null) as-is), and ensure setIsLoading(false) is called in the promise
resolution path (e.g., in a finally block or after catch) so the spinner shows
while the API request runs; update any try/catch/finally around generateSummary
to guarantee setIsLoading(false) on both success and error.
- Around line 154-158: The empty-state copy in the CourseSummary component is
misleading—update the JSX in CourseSummary.tsx (the empty-state div that
currently says "Click 'Generate Summary' to analyze student reviews") to reflect
the actual UI action (e.g., "Click 'Refresh' to analyze student reviews" or a
neutral message like "No reviews available; click Refresh"), so the placeholder
matches the existing "Refresh" button and won't confuse users.
- Around line 56-59: The useEffect that auto-generates a summary calls
generateSummary but omits it from the dependency array, risking a stale closure;
fix by either wrapping generateSummary in useCallback (so its identity is
stable) and then adding generateSummary to the useEffect deps, or by inlining
the summary-fetching logic directly inside the useEffect and keeping [courseId]
as the dependency; ensure you reference the generateSummary function and the
courseId variable when updating dependencies.
🧹 Nitpick comments (17)
.env.example (1)

9-11: Minor formatting nits from static analysis.

The static analysis tool flagged two minor issues:

  1. Key ordering: NEXT_PUBLIC_SUPABASE_ANON_KEY should come before NEXT_PUBLIC_SUPABASE_URL alphabetically
  2. Missing blank line at the end of the file

These are low-priority style nits. The important thing is that GEMINI_API_KEY correctly lacks the NEXT_PUBLIC_ prefix, ensuring it remains server-side only.

💅 Optional fix for linter warnings
-NEXT_PUBLIC_SUPABASE_URL=http://localhost:1234
-NEXT_PUBLIC_SUPABASE_ANON_KEY=local-testing-key
+NEXT_PUBLIC_SUPABASE_ANON_KEY=local-testing-key
+NEXT_PUBLIC_SUPABASE_URL=http://localhost:1234

 GEMINI_API_KEY=your_api_key_here
+
src/components/courses/compare/CourseSelector.tsx (2)

3-3: Remove unused import.

useEffect is imported but not used in this component.

🧹 Remove unused import
-import { useState, useEffect } from 'react';
+import { useState } from 'react';

69-74: Consider adding accessible label to the remove button.

The remove button uses only an icon (X), which may not be accessible to screen readers. Adding an aria-label improves accessibility.

♿ Proposed fix for accessibility
               <button
                 onClick={() => handleRemoveCourse(course.id)}
                 className="ml-1 hover:text-destructive transition-colors"
+                aria-label={`Remove ${course.code} from comparison`}
               >
                 <X className="h-3 w-3" />
               </button>
src/components/courses/CompareCoursesButton.tsx (2)

36-38: Consider replacing alert() with a more polished notification.

Using alert() blocks the UI thread and provides a jarring UX. Consider using a toast notification component for a more seamless experience, especially since this is a user-facing action.


9-9: Centralize STORAGE_KEY to avoid drift.

The STORAGE_KEY constant is duplicated across three comparison-related components: CompareCoursesButton.tsx, CompareButton.tsx, and AddToComparison.tsx, all with the same value ('course_comparison_list'). Moving this to src/constants (which already exists in the codebase) would prevent accidental mismatches if the key name needs to change.

src/app/api/extract-themes/route.ts (2)

6-6: Remove unused courseCode from destructuring.

courseCode is extracted from the request body but never used in the route handler. Either remove it or use it (e.g., in logging or the prompt for additional context).

🧹 Proposed fix
-    const { courseId, courseCode } = await request.json();
+    const { courseId } = await request.json();

16-22: Consider adding rate limiting or caching for this endpoint.

This endpoint fetches reviews and calls an external AI API on every request. For popular courses, this could lead to:

  1. Excessive Supabase queries
  2. High Gemini API costs
  3. Rate limiting from Google

Consider caching the extracted themes (e.g., in Supabase or Redis) with a TTL, and only regenerating when reviews are updated.

src/components/courses/course_page/CourseKeyThemes.tsx (2)

24-56: Missing loading state initialization before fetch.

Line 27 sets setIsLoading(true), but the initial state on Line 21 already sets isLoading to true. This is fine for the initial mount but causes a brief flash if re-fetching. Also, courseCode is included in the dependency array but isn't validated before calling the fetch.

Consider simplifying: either remove Line 27 (redundant on mount) or validate both props:

🔧 Suggested improvement
-    if (courseId) {
+    if (courseId && courseCode) {
       fetchKeyThemes();
     }

30-36: Consider using GET instead of POST for data retrieval.

This endpoint fetches data without side effects, which aligns better with GET semantics. Using POST for read-only operations can cause issues with caching and doesn't follow REST conventions. If POST is required for payload size reasons, this is acceptable but worth documenting.

src/components/courses/CompareButton.tsx (1)

62-65: Replace alert() with toast notifications.

Using alert() blocks the main thread and provides poor UX. Consider using a toast notification system (if available in your UI library) for a more polished experience.

Also applies to: 120-123

src/components/courses/compare/ReviewHighlights.tsx (2)

56-73: N+1 query pattern: Sequential fetches can be optimized.

The code fetches reviews for each course in a sequential loop, resulting in N database calls. This can be optimized by batching the query using .in('target_id', uuids) and then grouping results client-side.

♻️ Suggested optimization
-      // Fetch reviews for each course UUID
-      for (const course of courses) {
-        const uuid = codeToUUID[course.code.toUpperCase()];
-        if (!uuid) continue;
-
-        const { data, error } = await supabase
-          .from('reviews')
-          .select('id, comment, rating_value, difficulty_rating, workload_rating, created_at, target_id')
-          .eq('target_id', uuid)
-          .eq('target_type', 'course')
-          .not('comment', 'is', null)
-          .order('created_at', { ascending: false })
-          .limit(10);
-
-        if (!error && data) {
-          reviewsByUUID[course.id] = data;
-        }
-      }
+      // Batch fetch reviews for all course UUIDs
+      const uuids = Object.values(codeToUUID);
+      if (uuids.length === 0) {
+        setLoading(false);
+        return;
+      }
+
+      const { data: allReviews, error } = await supabase
+        .from('reviews')
+        .select('id, comment, rating_value, difficulty_rating, workload_rating, created_at, target_id')
+        .in('target_id', uuids)
+        .eq('target_type', 'course')
+        .not('comment', 'is', null)
+        .order('created_at', { ascending: false });
+
+      if (!error && allReviews) {
+        // Group by target_id and limit to 10 per course
+        const uuidToCourseId = Object.fromEntries(
+          courses.map(c => [codeToUUID[c.code.toUpperCase()], c.id])
+        );
+        for (const review of allReviews) {
+          const courseId = uuidToCourseId[review.target_id];
+          if (!courseId) continue;
+          if (!reviewsByUUID[courseId]) reviewsByUUID[courseId] = [];
+          if (reviewsByUUID[courseId].length < 10) {
+            reviewsByUUID[courseId].push(review);
+          }
+        }
+      }

140-147: Potential undefined value for Tabs defaultValue.

If courses[0]?.id is undefined (empty array case), the Tabs component may behave unexpectedly. While line 110 guards against empty courses, adding a fallback is safer.

🔧 Suggested fix
-          <Tabs defaultValue={courses[0]?.id} className="w-full">
+          <Tabs defaultValue={courses[0]?.id || ''} className="w-full">
src/app/courses/page.tsx (1)

5-7: Consider handling loading state for courses.

The useCourses hook returns { courses, isLoading, error }, but only courses is destructured. The ComparisonFloatingButton receives courses which may be empty while loading. Per the relevant code snippet for ComparisonFloatingButton, it returns null when no courses are selected, but the initial empty courses array during loading could cause brief UI inconsistencies.

This is a minor concern since the floating button handles empty selection gracefully, but for consistency with the comparison page (src/app/courses/compare/page.tsx lines 18, 22) which uses isLoading, you may want to handle the loading state here as well.

Also applies to: 19-19

src/components/courses/compare/ComparisonTable.tsx (1)

143-163: Consider using a stable key instead of array index.

Line 147 uses idx as the key for TableRow. While the comparisonRows array is stable within a render, using row.label as the key would be more semantically correct and resilient to future changes.

♻️ Suggested fix
-                  <TableRow
-                    key={idx}
+                  <TableRow
+                    key={row.label}
                     className="hover:bg-muted/30 transition-colors"
                   >
src/components/courses/compare/ComparisonCharts.tsx (2)

31-34: Remove unused getDepartmentColor function.

This helper function is defined but never used in this component. It appears to be copy-pasted from ComparisonTable.tsx. Consider removing it to reduce dead code.

♻️ Suggested fix
-  const getDepartmentColor = (department: string) => {
-    const dept = departmentProperties.find((d) => d.name === department);
-    return dept?.color || '#718096';
-  };
-

Also remove the unused import on line 20:

-import departmentProperties from '@/constants/department';

44-67: Remove unused idx parameter from reduce callbacks.

The idx parameter is declared but never used in the reduce callbacks. This is a minor cleanup.

♻️ Suggested fix
     {
       metric: 'Overall',
-      ...courses.reduce((acc, course, idx) => {
+      ...courses.reduce((acc, course) => {
         acc[course.code] = course.overall_rating;
         return acc;
       }, {} as Record<string, number>),
     },

Apply similar changes to the other reduce callbacks on lines 55 and 62.

docs/COURSE_COMPARISON_FEATURE.md (1)

163-163: Documentation claims memoization that isn't implemented.

The documentation states "Charts data is memoized to prevent unnecessary recalculations", but the ComparisonCharts.tsx component doesn't use useMemo for its data transformations (ratingsData, radarData, creditsData). Consider either implementing memoization or updating the documentation to be accurate.

Comment on lines +148 to +150
```
/courses/compare?courses=MAL401,MAL402
```
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language specifier to fenced code block.

The code block is missing a language identifier, which helps with syntax highlighting and accessibility.

📝 Suggested fix
 ### Example 1: Compare Two Math Courses
-```
+```text
 /courses/compare?courses=MAL401,MAL402
 ```
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
/courses/compare?courses=MAL401,MAL402
```
🧰 Tools
🪛 markdownlint-cli2 (0.20.0)

148-148: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In `@docs/COURSE_COMPARISON_FEATURE.md` around lines 148 - 150, The fenced code
block showing the example request "/courses/compare?courses=MAL401,MAL402" is
missing a language specifier; update that block to include a language identifier
(e.g., "text" or "http") so the snippet is syntax-highlighted and
accessible—locate the fenced block containing
"/courses/compare?courses=MAL401,MAL402" and change the opening backticks to
include the chosen language (for example, ```text).

Comment on lines +52 to +102
const geminiResponse = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${geminiApiKey}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
contents: [
{
parts: [
{
text: `Analyze these student course reviews and extract the 6-8 most common themes or topics mentioned.

Reviews:
${reviewsText}

For each theme:
1. Create a short, clear tag (2-4 words max)
2. Count how many times it appears (approximately)
3. Determine sentiment: positive, negative, or neutral

Examples of good tags:
- "Heavy Workload" (negative)
- "Engaging Lectures" (positive)
- "Tough Exams" (negative)
- "Helpful Professor" (positive)
- "Group Projects" (neutral)
- "Practical Applications" (positive)
- "Fast Paced" (neutral)

Return ONLY a valid JSON array in this exact format:
[
{"tag": "Heavy Workload", "count": 5, "sentiment": "negative"},
{"tag": "Engaging Lectures", "count": 3, "sentiment": "positive"}
]

Important: Return ONLY the JSON array, no other text or explanation.`,
},
],
},
],
generationConfig: {
temperature: 0.3,
topK: 20,
topP: 0.8,
maxOutputTokens: 300,
},
}),
}
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a timeout to the Gemini API fetch request.

The fetch call to the Gemini API has no timeout configured. If the external service is slow or unresponsive, this could cause the request to hang indefinitely, potentially exhausting server resources.

⏱️ Proposed fix using AbortController
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
+
     const geminiResponse = await fetch(
       `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${geminiApiKey}`,
       {
         method: 'POST',
         headers: {
           'Content-Type': 'application/json',
         },
+        signal: controller.signal,
         body: JSON.stringify({
           // ... existing body
         }),
       }
     );
+
+    clearTimeout(timeoutId);

You'll also want to handle AbortError in the catch block:

} catch (error) {
  if (error instanceof Error && error.name === 'AbortError') {
    return NextResponse.json(
      { error: 'Theme extraction timed out' },
      { status: 504 }
    );
  }
  // ... existing error handling
}
🤖 Prompt for AI Agents
In `@src/app/api/extract-themes/route.ts` around lines 52 - 102, The Gemini fetch
call (creating geminiResponse) has no timeout; wrap the request with an
AbortController, pass controller.signal into the fetch options for the gemini
request, start a timeout (e.g., setTimeout to call controller.abort() after a
chosen ms) and clearTimeout on success, and update the route handler's catch
block to detect an AbortError (error.name === 'AbortError') and return a 504
NextResponse.json({ error: 'Theme extraction timed out' }); ensure you reference
the existing geminiResponse fetch invocation and clear the timeout to avoid
leaks.

Comment on lines +52 to +53
const geminiResponse = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${geminiApiKey}`,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Gemini API authentication methods query parameter vs Authorization header

💡 Result:

For the Google Gemini API (Google AI for Developers / Generative Language API), the authentication method determines whether you use a query parameter or an HTTP header:

1) API key (recommended header; query param is fallback)

  • Header (preferred): send your key in x-goog-api-key.
    • Gemini API docs show requests using x-goog-api-key. [1]
    • Google Cloud’s general guidance for API keys also recommends X-goog-api-key. [2]
  • Query parameter (fallback): ?key=YOUR_API_KEY
    • Google Cloud docs note this works when you can’t set headers, but it exposes the key in URLs (logs, proxies, browser history), so it’s less safe. [2]

2) OAuth 2.0 access token (Authorization header)

  • Use the standard header: Authorization: Bearer ACCESS_TOKEN. [3][4]

3) Live API ephemeral tokens (special case)

  • Ephemeral tokens can be sent either:
    • as an access_token query parameter, or
    • in Authorization: Token <token> (auth-scheme Token, not Bearer). [5]

Bottom line:

  • Use x-goog-api-key header for API keys (avoid putting keys in the URL). [1][2]
  • Use Authorization: Bearer for OAuth. [3][4]
  • Only use query parameters when the specific feature requires it (e.g., Live API ephemeral tokens) or headers are impossible. [2][5]

Sources: [1] Gemini API reference (ai.google.dev), [2] Google Cloud “Use API keys” doc, [3] Gemini OAuth quickstart (ai.google.dev), [4] Google Cloud REST auth doc, [5] Gemini ephemeral tokens doc (ai.google.dev).


Use x-goog-api-key header instead of query parameter.

The Gemini API supports passing the API key via the x-goog-api-key header, which is Google's recommended approach. Query parameters expose the key in server logs, CDN logs, and browser history. Change the request to use the header method instead:

Example fix
const geminiResponse = await fetch(
  `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent`,
  {
    method: "POST",
    headers: {
      "x-goog-api-key": geminiApiKey,
      // ... other headers
    },
    body: JSON.stringify(/* ... */)
  }
);
🤖 Prompt for AI Agents
In `@src/app/api/extract-themes/route.ts` around lines 52 - 53, The fetch call
that creates geminiResponse currently sends the API key in the URL query string;
change the request to call the same endpoint without the ?key=... suffix and
pass the API key via the "x-goog-api-key" header instead. Update the
geminiResponse fetch invocation to include an options object (method, headers,
body) and add "x-goog-api-key": geminiApiKey to headers (keep other headers and
the JSON body intact). Ensure the request URL is
"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent"
and remove any query-parameter usage of geminiApiKey.

Comment on lines +4 to +13
export async function POST(request: NextRequest) {
try {
const { courseId, courseCode, courseTitle } = await request.json();

if (!courseId) {
return NextResponse.json(
{ error: 'Course ID is required' },
{ status: 400 }
);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unused parameters courseCode and courseTitle not validated.

While courseId is validated, courseCode and courseTitle are destructured but only used in the Gemini prompt. If these are undefined, the prompt will contain "undefined" text. Consider providing defaults or validating these fields.

🔧 Suggested fix
-    const { courseId, courseCode, courseTitle } = await request.json();
+    const { courseId, courseCode = 'Unknown', courseTitle = 'Unknown Course' } = await request.json();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function POST(request: NextRequest) {
try {
const { courseId, courseCode, courseTitle } = await request.json();
if (!courseId) {
return NextResponse.json(
{ error: 'Course ID is required' },
{ status: 400 }
);
}
export async function POST(request: NextRequest) {
try {
const { courseId, courseCode = 'Unknown', courseTitle = 'Unknown Course' } = await request.json();
if (!courseId) {
return NextResponse.json(
{ error: 'Course ID is required' },
{ status: 400 }
);
}
🤖 Prompt for AI Agents
In `@src/app/api/generate-summary/route.ts` around lines 4 - 13, The POST handler
destructures courseCode and courseTitle but does not validate them, causing
"undefined" to appear in the Gemini prompt; update the POST function to either
validate and return a 400 when courseCode or courseTitle are missing (similar to
the existing courseId check) or assign safe defaults (e.g., empty string or
"Unknown") before using them in the prompt; modify the destructuring/validation
in POST and ensure any later use of courseCode/courseTitle (in the Gemini prompt
construction) uses the validated or defaulted values.

Comment on lines +61 to +102
const geminiResponse = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${geminiApiKey}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
contents: [
{
parts: [
{
text: `You are an academic advisor analyzing student reviews for a course.

Course: ${courseTitle} (${courseCode})
Total Reviews: ${reviews.length}

Student Reviews:
${reviewsText}

Based on these reviews, generate a concise, informative summary (150-200 words) that covers:

1. **Overall Experience**: General sentiment and key takeaways
2. **Strengths**: What students appreciated most
3. **Challenges**: Common difficulties or concerns mentioned
4. **Workload & Difficulty**: Average perception of course demands
5. **Recommendations**: Who would benefit most from this course

Write in a professional, helpful tone. Be balanced and objective. Use bullet points for clarity where appropriate.`,
},
],
},
],
generationConfig: {
temperature: 0.7,
topK: 40,
topP: 0.95,
maxOutputTokens: 500,
},
}),
}
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing timeout on external API call.

The Gemini API call has no timeout configured. If the external service is slow or unresponsive, this could cause request threads to hang indefinitely, degrading user experience and potentially exhausting server resources.

🛡️ Proposed fix using AbortController
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout
+
     const geminiResponse = await fetch(
       `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${geminiApiKey}`,
       {
         method: 'POST',
         headers: {
           'Content-Type': 'application/json',
         },
+        signal: controller.signal,
         body: JSON.stringify({
           // ... existing body
         }),
       }
     );
+
+    clearTimeout(timeoutId);
🤖 Prompt for AI Agents
In `@src/app/api/generate-summary/route.ts` around lines 61 - 102, The fetch call
that creates geminiResponse lacks a timeout and may hang; wrap the request in an
AbortController with a timer (e.g., create AbortController(), set a setTimeout
to call controller.abort() after your desired timeout) and pass
controller.signal into the fetch options for the request that builds the Gemini
payload, clear the timeout after fetch completes, and handle the abort/timeout
case (detect AbortError or signal.aborted) in the surrounding handler that
constructs the response so you return a proper error instead of hanging; modify
the code where geminiResponse is awaited to include the controller, timeout, and
cleanup.

Comment on lines +16 to +20
const getComparisonList = (): string[] => {
if (typeof window === 'undefined') return [];
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against malformed localStorage data to avoid runtime crashes.

JSON.parse can throw if the stored payload is corrupted or not JSON, which will break the component on render. Add a safe parse with array validation and fallback.

🔧 Suggested fix
 const getComparisonList = (): string[] => {
   if (typeof window === 'undefined') return [];
   const stored = localStorage.getItem(STORAGE_KEY);
-  return stored ? JSON.parse(stored) : [];
+  if (!stored) return [];
+  try {
+    const parsed = JSON.parse(stored);
+    return Array.isArray(parsed) ? parsed : [];
+  } catch {
+    return [];
+  }
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const getComparisonList = (): string[] => {
if (typeof window === 'undefined') return [];
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
};
const getComparisonList = (): string[] => {
if (typeof window === 'undefined') return [];
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return [];
try {
const parsed = JSON.parse(stored);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
🤖 Prompt for AI Agents
In `@src/components/courses/course_page/AddToComparison.tsx` around lines 16 - 20,
getComparisonList currently calls JSON.parse on localStorage data which can
throw or return non-array values; wrap the parse in a try/catch, validate the
parsed value is an array of strings, and return [] on error or invalid shape. In
the getComparisonList function, read localStorage.getItem(STORAGE_KEY) then try
JSON.parse inside try/catch, check Array.isArray(result) and that every item is
typeof 'string' (or coerce/ignore non-strings), and optionally remove the
malformed STORAGE_KEY entry before returning [].

Comment on lines +22 to +25
const setComparisonList = (courseIds: string[]) => {
if (typeof window === 'undefined') return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(courseIds));
window.dispatchEvent(new Event('comparison-list-updated'));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize the comparison list to prevent duplicates from inflating counts.

Without de-dupe, the same course ID can appear multiple times (e.g., cross-component updates or stale storage), which can block adding new courses due to the 4-item cap. Consider normalizing in setComparisonList.

🔧 Suggested fix
 const setComparisonList = (courseIds: string[]) => {
   if (typeof window === 'undefined') return;
-  localStorage.setItem(STORAGE_KEY, JSON.stringify(courseIds));
+  const uniqueIds = Array.from(new Set(courseIds));
+  localStorage.setItem(STORAGE_KEY, JSON.stringify(uniqueIds));
   window.dispatchEvent(new Event('comparison-list-updated'));
 };

Also applies to: 45-52

🤖 Prompt for AI Agents
In `@src/components/courses/course_page/AddToComparison.tsx` around lines 22 - 25,
Normalize and dedupe the comparison list inside setComparisonList by removing
duplicate course IDs before writing to localStorage and dispatching the update
event; update the implementation in setComparisonList to create a deduplicated
array (preserving order) from the incoming courseIds, JSON.stringify and store
that deduped array, then dispatch 'comparison-list-updated'. Also apply the same
normalization wherever the comparison is updated/created (the related update
handlers around lines 45-52) so stored lists never contain duplicates and the
4-item cap behaves correctly.

Comment on lines +22 to +24
const generateSummary = async () => {
setIsLoading(false);
setError(null);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical bug: Loading state set to false instead of true.

Line 23 sets setIsLoading(false) at the start of the fetch operation, which should be true. This causes the loading spinner to never display during the API call, and the UI will show stale content or the empty state while fetching.

🐛 Fix the loading state
   const generateSummary = async () => {
-    setIsLoading(false);
+    setIsLoading(true);
     setError(null);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const generateSummary = async () => {
setIsLoading(false);
setError(null);
const generateSummary = async () => {
setIsLoading(true);
setError(null);
🤖 Prompt for AI Agents
In `@src/components/courses/course_page/CourseSummary.tsx` around lines 22 - 24,
In generateSummary, the loading state is incorrectly set to false at the start;
change the initial call to setIsLoading(true) (leave setError(null) as-is), and
ensure setIsLoading(false) is called in the promise resolution path (e.g., in a
finally block or after catch) so the spinner shows while the API request runs;
update any try/catch/finally around generateSummary to guarantee
setIsLoading(false) on both success and error.

Comment on lines +56 to +59
useEffect(() => {
// Auto-generate summary on mount
generateSummary();
}, [courseId]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing dependency in useEffect causes stale closure.

The useEffect calls generateSummary but doesn't include it in the dependency array. This can cause stale closure issues where generateSummary captures outdated props. Either wrap generateSummary in useCallback or inline the fetch logic.

🔧 Option 1: Use useCallback
+import { useState, useEffect, useCallback } from 'react';
...
-  const generateSummary = async () => {
+  const generateSummary = useCallback(async () => {
     setIsLoading(true);
     setError(null);
     // ... rest of function
-  };
+  }, [courseId, courseCode, courseTitle]);

   useEffect(() => {
     generateSummary();
-  }, [courseId]);
+  }, [generateSummary]);
🤖 Prompt for AI Agents
In `@src/components/courses/course_page/CourseSummary.tsx` around lines 56 - 59,
The useEffect that auto-generates a summary calls generateSummary but omits it
from the dependency array, risking a stale closure; fix by either wrapping
generateSummary in useCallback (so its identity is stable) and then adding
generateSummary to the useEffect deps, or by inlining the summary-fetching logic
directly inside the useEffect and keeping [courseId] as the dependency; ensure
you reference the generateSummary function and the courseId variable when
updating dependencies.

Comment on lines +154 to +158
) : (
<div className="text-center py-8 text-muted-foreground">
<Sparkles className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>Click "Generate Summary" to analyze student reviews</p>
</div>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misleading placeholder text.

The empty state shows "Click 'Generate Summary' to analyze student reviews", but there's no "Generate Summary" button—only a "Refresh" button. This text won't display in normal flow (auto-generates on mount), but if it did, it would confuse users.

🔧 Suggested fix
-            <p>Click "Generate Summary" to analyze student reviews</p>
+            <p>Click "Refresh" to generate the course summary</p>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
) : (
<div className="text-center py-8 text-muted-foreground">
<Sparkles className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>Click "Generate Summary" to analyze student reviews</p>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Sparkles className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>Click "Refresh" to generate the course summary</p>
</div>
🤖 Prompt for AI Agents
In `@src/components/courses/course_page/CourseSummary.tsx` around lines 154 - 158,
The empty-state copy in the CourseSummary component is misleading—update the JSX
in CourseSummary.tsx (the empty-state div that currently says "Click 'Generate
Summary' to analyze student reviews") to reflect the actual UI action (e.g.,
"Click 'Refresh' to analyze student reviews" or a neutral message like "No
reviews available; click Refresh"), so the placeholder matches the existing
"Refresh" button and won't confuse users.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds AI-driven review analysis to course pages (key themes + summaries via Gemini) and introduces a new course comparison workflow (compare page, selection UI, charts, review highlights) accessible from the navbar and course list.

Changes:

  • Added Gemini-backed API routes to generate course summaries and extract key themes from reviews.
  • Integrated AI summary and key-theme badges into the course detail page sidebar/content.
  • Implemented a course comparison feature (UI, navigation entry points, and documentation).

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 22 comments.

Show a summary per file
File Description
src/components/layout/Navbar.tsx Adds a “Compare” nav link to reach the comparison flow.
src/components/courses/course_page/CourseSummary.tsx New client component to request and render AI-generated course summaries.
src/components/courses/course_page/CourseKeyThemes.tsx New client component to request and render extracted theme badges with sentiment.
src/components/courses/course_page/AddToComparison.tsx Adds course-page sidebar controls for managing comparison selection via localStorage.
src/components/courses/compare/ReviewHighlights.tsx New comparison view section to fetch and display review highlights per selected course.
src/components/courses/compare/CourseSelector.tsx New searchable selector for adding/removing courses in compare mode.
src/components/courses/compare/ComparisonTable.tsx New side-by-side metrics table for selected courses.
src/components/courses/compare/ComparisonCharts.tsx New charts (bar/radar/credits) for visual comparison of selected courses.
src/components/courses/ItemCard.tsx Adds a compare affordance on course cards.
src/components/courses/CompareCoursesButton.tsx Adds a “Compare Courses” CTA and badge count using stored comparison list.
src/components/courses/CompareButton.tsx Adds compare toggle button + floating comparison drawer using localStorage state.
src/app/courses/page.tsx Adds compare entry points (sidebar button + floating button) on the courses list page.
src/app/courses/compare/page.tsx New comparison page (URL preselection, selector, table/charts/review sections).
src/app/courses/[courseId]/page.tsx Integrates AI summary, key themes, and add-to-comparison into course detail page.
src/app/api/generate-summary/route.ts New API endpoint to fetch reviews and generate a Gemini summary.
src/app/api/extract-themes/route.ts New API endpoint to fetch reviews and extract key themes via Gemini.
docs/COURSE_COMPARISON_FEATURE.md Adds documentation for the new course comparison feature.
CONTRIBUTION.md Documents required GEMINI_API_KEY env var for AI features.
.env.example Adds GEMINI_API_KEY placeholder.
Comments suppressed due to low confidence (1)

CONTRIBUTION.md:72

  • After adding GEMINI_API_KEY as item #5, the subsequent heading still says “### 5. Run Development Server”, which makes the numbering inconsistent. Renumber this section (and any subsequent references) to keep the setup steps clear.
5. GEMINI_API_KEY

This is your Google Gemini API key for AI-powered course summaries. Get it from [Google AI Studio](https://makersuite.google.com/app/apikey). This key is used server-side only and should never be exposed to the frontend.

### 5. Run Development Server
```bash
npm run dev
</details>



---

💡 <a href="/OpenLake/RateMyCourse/new/main/.github/instructions?filename=*.instructions.md" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Add Copilot custom instructions</a> for smarter, more guided reviews. <a href="https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Learn how to get started</a>.

) : (
<div className="text-center py-8 text-muted-foreground">
<Sparkles className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>Click "Generate Summary" to analyze student reviews</p>
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty-state copy says “Click "Generate Summary"…”, but the button label is “Refresh”. Either update the copy to match the button label or change the button text for consistency.

Suggested change
<p>Click "Generate Summary" to analyze student reviews</p>
<p>Click "Refresh" to analyze student reviews</p>

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +71
<button
onClick={() => handleRemoveCourse(course.id)}
className="ml-1 hover:text-destructive transition-colors"
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remove-course icon button has no accessible name, so screen readers will announce it ambiguously. Add an aria-label (e.g., “Remove from comparison”) and consider type="button" to avoid accidental form submission if this ever renders inside a <form>.

Suggested change
<button
onClick={() => handleRemoveCourse(course.id)}
className="ml-1 hover:text-destructive transition-colors"
<button
type="button"
onClick={() => handleRemoveCourse(course.id)}
className="ml-1 hover:text-destructive transition-colors"
aria-label={`Remove ${course.code} from comparison`}

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +34
import departmentProperties from '@/constants/department';

interface ComparisonChartsProps {
courses: Course[];
}

export default function ComparisonCharts({ courses }: ComparisonChartsProps) {
if (courses.length === 0) {
return null;
}

const getDepartmentColor = (department: string) => {
const dept = departmentProperties.find((d) => d.name === department);
return dept?.color || '#718096';
};
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

departmentProperties and getDepartmentColor are declared but never used in this file. Please remove them to avoid dead code and confusion.

Copilot uses AI. Check for mistakes.
Comment on lines +2 to +3
import { supabase } from '@/lib/supabase';

Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This route imports supabase from @/lib/supabase, which is a browser client (createBrowserClient) and is not safe/appropriate for Next.js route handlers. Please switch to a server-side Supabase client (e.g. createServerClient from src/utils/supabase/server.ts, or an admin client with the service role key).

Suggested change
import { supabase } from '@/lib/supabase';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL as string,
(process.env.SUPABASE_SERVICE_ROLE_KEY ||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) as string
);

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +27
if (!isLoading && courses.length > 0 && searchParams) {
const courseIds = searchParams.get('courses')?.split(',') || [];
if (courseIds.length > 0) {
const preselectedCourses = courses.filter((c) =>
courseIds.includes(c.id)
);
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preselecting courses from the courses query param is currently case-sensitive (courseIds.includes(c.id)), but course IDs in result.json are lowercase while users may paste/share uppercase codes. Normalizing the parsed IDs (e.g., .map(id => id.toLowerCase().trim())) before matching would make share links more robust.

Copilot uses AI. Check for mistakes.

- **Lazy Loading**: Reviews are fetched only when comparison page is opened
- **Optimistic Updates**: UI updates immediately when adding/removing courses
- **Memoization**: Charts data is memoized to prevent unnecessary recalculations
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “Memoization” claim here doesn’t match the implementation: ComparisonCharts computes ratingsData/radarData/creditsData inline and doesn’t use useMemo. Either add memoization in code or remove this statement to keep the docs accurate.

Suggested change
- **Memoization**: Charts data is memoized to prevent unnecessary recalculations

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +46
const [comparisonCount, setComparisonCount] = useState(0);

useEffect(() => {
const updateState = () => {
const list = getComparisonList();
setIsInComparison(list.includes(course.id));
setComparisonCount(list.length);
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable comparisonCount.

Suggested change
const [comparisonCount, setComparisonCount] = useState(0);
useEffect(() => {
const updateState = () => {
const list = getComparisonList();
setIsInComparison(list.includes(course.id));
setComparisonCount(list.length);
useEffect(() => {
const updateState = () => {
const list = getComparisonList();
setIsInComparison(list.includes(course.id));

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +35
const getDepartmentColor = (department: string) => {
const dept = departmentProperties.find((d) => d.name === department);
return dept?.color || '#718096';
};

Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable getDepartmentColor.

Suggested change
const getDepartmentColor = (department: string) => {
const dept = departmentProperties.find((d) => d.name === department);
return dept?.color || '#718096';
};

Copilot uses AI. Check for mistakes.
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Check, ChevronsUpDown, Plus, X } from 'lucide-react';
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import Check.

Suggested change
import { Check, ChevronsUpDown, Plus, X } from 'lucide-react';

Copilot uses AI. Check for mistakes.
PopoverTrigger,
} from '@/components/ui/popover';
import { Check, ChevronsUpDown, Plus, X } from 'lucide-react';
import { cn } from '@/lib/utils';
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import cn.

Suggested change
import { cn } from '@/lib/utils';

Copilot uses AI. Check for mistakes.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (20)
src/lib/check-db.ts-44-44 (1)

44-44: ⚠️ Potential issue | 🟠 Major

Avoid import-time execution for a service-role DB script.

Running checkDatabase() at import time can trigger unintended live DB calls and side effects. Export the function and invoke it from a dedicated CLI/script entrypoint instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/check-db.ts` at line 44, Remove the top-level invocation of
checkDatabase() so the module does not perform DB calls at import time; ensure
checkDatabase remains exported (export async function checkDatabase(...)) and
create a separate CLI/script entrypoint that imports checkDatabase, calls it,
awaits errors, logs failures and exits with non-zero status on error. Also
verify the module has no other side-effectful calls so importing it is safe for
service-role code.
src/lib/check-db.ts-4-7 (1)

4-7: ⚠️ Potential issue | 🟠 Major

Fail fast on missing Supabase env vars.

Using non-null assertions here can mask config issues and fail later with less actionable errors. Add explicit validation before creating the client.

Proposed fix
-const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
-const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
+const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
+
+if (!supabaseUrl || !supabaseServiceKey) {
+  throw new Error(
+    'Missing required env vars: NEXT_PUBLIC_SUPABASE_URL and/or SUPABASE_SERVICE_ROLE_KEY'
+  );
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/check-db.ts` around lines 4 - 7, The code uses non-null assertions
for supabaseUrl and supabaseServiceKey which can hide missing config; before
calling createClient, explicitly validate process.env.NEXT_PUBLIC_SUPABASE_URL
and process.env.SUPABASE_SERVICE_ROLE_KEY (the variables assigned to supabaseUrl
and supabaseServiceKey), and if either is missing log a clear error and exit (or
throw) so the app fails fast; then pass the validated values to createClient to
construct supabase.
src/lib/check-db.ts-35-39 (1)

35-39: ⚠️ Potential issue | 🟠 Major

Handle reviews query errors before logging success.

Line 39 logs a success count even if the query fails. Please mirror the error handling used for professors/courses.

Proposed fix
-  const { count: reviewCount } = await supabase
+  const { count: reviewCount, error: reviewError } = await supabase
     .from('reviews')
     .select('*', { count: 'exact', head: true });
-  
-  console.log(`✅ Reviews: ${reviewCount || 0} records`);
+
+  if (reviewError) {
+    console.error('❌ Error checking reviews:', reviewError.message);
+  } else {
+    console.log(`✅ Reviews: ${reviewCount ?? 0} records`);
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/check-db.ts` around lines 35 - 39, The reviews query currently logs a
success count unconditionally; update the supabase.from('reviews') call that
returns { count: reviewCount } to also capture and check the returned error
(e.g., const { count: reviewCount, error: reviewsError } = await
supabase.from('reviews').select(...)); if reviewsError is truthy, call
processLogger.error (or console.error if consistent with professors/courses
handling) with the error and exit (process.exit(1)), otherwise log the count
with the existing message using reviewCount || 0.
docs/SETUP_GUIDE.md-9-12 (1)

9-12: ⚠️ Potential issue | 🟠 Major

Avoid hardcoded Supabase project URL in shared setup docs.

This points everyone to one specific instance. Use a placeholder (or .env variable reference) so contributors use their own project.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/SETUP_GUIDE.md` around lines 9 - 12, Replace the hardcoded Supabase
project URL in the "Open your Supabase Dashboard" section with a placeholder or
environment variable reference (e.g., SUPABASE_URL or <your-supabase-url>) so
contributors use their own project; update the text under the heading "Open your
Supabase Dashboard" to instruct readers to insert their own Supabase dashboard
URL or refer to their .env variable instead of the specific URL currently shown.
setup.sh-23-27 (1)

23-27: ⚠️ Potential issue | 🟠 Major

Fail fast if migration inputs are missing before creating setup_database.sql.

The script currently assumes both SQL files exist; a missing file can produce a broken combined script and continue.

Proposed fix
 # Check if setup_database.sql exists
 if [ ! -f setup_database.sql ]; then
     echo "⚠️  Combined SQL file not found. Creating it..."
+    [ -f src/migrations/migration.sql ] || { echo "❌ Missing src/migrations/migration.sql"; exit 1; }
+    [ -f src/migrations/sentiment_analysis.sql ] || { echo "❌ Missing src/migrations/sentiment_analysis.sql"; exit 1; }
     cat src/migrations/migration.sql src/migrations/sentiment_analysis.sql > setup_database.sql
     echo "✅ Created setup_database.sql"
 fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@setup.sh` around lines 23 - 27, The script currently concatenates
src/migrations/migration.sql and src/migrations/sentiment_analysis.sql into
setup_database.sql without verifying the inputs; update the conditional block
that creates setup_database.sql to first check that both
src/migrations/migration.sql and src/migrations/sentiment_analysis.sql exist,
and if either is missing print a clear error identifying the missing file(s) and
exit with a non‑zero status so the script fails fast instead of producing a
broken combined file.
src/pages/api/ratings/route.ts-18-21 (1)

18-21: ⚠️ Potential issue | 🟠 Major

localhost fallback can break sentiment calls outside local dev.

Using process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000' makes background analysis fragile in deployed environments.

Proposed fix
-async function analyzeSentimentAsync(
+async function analyzeSentimentAsync(
+  origin: string,
   reviewId: string,
   comment: string,
   targetType: 'course' | 'professor'
 ): Promise<void> {
   try {
-    // Get the base URL for internal API calls
-    const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
-    
-    const response = await fetch(`${baseUrl}/api/analyze-sentiment`, {
+    const response = await fetch(`${origin}/api/analyze-sentiment`, {
       method: 'POST',
-      analyzeSentimentAsync(data.id, sanitizedComment, targetType).catch((err) => {
+      const origin = new URL(request.url).origin;
+      analyzeSentimentAsync(origin, data.id, sanitizedComment, targetType).catch((err) => {
         console.error('Error in background sentiment analysis:', err);
         // Log error but don't fail the rating submission
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/ratings/route.ts` around lines 18 - 21, Replace the unsafe
localhost fallback: stop using process.env.NEXT_PUBLIC_BASE_URL ||
'http://localhost:3000' and instead require a real base URL (or derive it from
the incoming request) before calling fetch; update the code that sets baseUrl
(the baseUrl constant) so it either reads process.env.NEXT_PUBLIC_BASE_URL and
throws/logs an error if missing, or builds the URL from the current
request/origin, then use that baseUrl when calling
fetch(`${baseUrl}/api/analyze-sentiment`, ...) to avoid hardcoded localhost in
production.
setup.sh-17-17 (1)

17-17: ⚠️ Potential issue | 🟠 Major

Replace fragile .env parsing with safe shell loading.

export $(cat .env | ... | xargs) breaks on spaces/quotes and can mis-parse valid env values.

Proposed fix
+set -euo pipefail
+
 # Load environment variables
-export $(cat .env | grep -v '^#' | xargs)
+set -a
+# shellcheck disable=SC1091
+source .env
+set +a
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@setup.sh` at line 17, Replace the fragile export $(cat .env | ... | xargs)
line with a safe sourcing approach: check .env exists, enable automatic export
(allexport), source the .env file using a safe shell source (dot) to preserve
quoting/spaces, then disable automatic export; this avoids word-splitting and
preserves values — update the setup.sh snippet that contains the exact export
$(cat .env | grep -v '^#' | xargs) invocation accordingly.
src/pages/api/ratings/route.ts-153-160 (1)

153-160: ⚠️ Potential issue | 🟠 Major

Fire-and-forget promises can be interrupted in serverless request lifecycles.

The code uses .catch() on an unawaited promise, but this does not guarantee the sentiment analysis completes after the response is sent. Next.js Route Handlers do not keep the serverless invocation alive for unawaited tasks, especially on Vercel. For Next.js 13.5.1, consider:

  • Using waitUntil() from @vercel/functions to extend the handler lifetime for post-response work
  • Queueing the sentiment analysis via Vercel Queues or an external queue service
  • Awaiting the task if timing allows
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/ratings/route.ts` around lines 153 - 160, The current
fire-and-forget call to analyzeSentimentAsync(data.id, sanitizedComment,
targetType) may be terminated in serverless lifecycles; replace the unawaited
.catch() pattern with a lifecycle-safe approach: call the Vercel waitUntil
handler (import waitUntil from '@vercel/functions') and pass the
analyzeSentimentAsync promise into waitUntil(...) so the runtime keeps the
invocation alive, or alternatively enqueue the sentiment job to your queue
service (e.g., Vercel Queues) instead of calling analyzeSentimentAsync directly;
update the code around analyzeSentimentAsync(...) to use waitUntil(...) or a
queue client send/enqueue method and keep error handling inside that
deferred/queued task.
src/app/api/batch-analyze-sentiment/route.ts-49-70 (1)

49-70: ⚠️ Potential issue | 🟠 Major

Missing timeout and retry logic (inconsistent with single-review route).

This batch route lacks the timeout and retry logic that exists in /api/analyze-sentiment. For batch operations where one failure shouldn't block the entire batch, at minimum add a timeout to prevent hanging on slow Gemini responses.

🛡️ Proposed fix with timeout
+  const controller = new AbortController();
+  const timeoutId = setTimeout(() => controller.abort(), 30000);
+
   const response = await fetch(
-    `https://generativelanguage.googleapis.com/v1beta/models/${SENTIMENT_CONFIG.gemini.model}:generateContent?key=${geminiApiKey}`,
+    `https://generativelanguage.googleapis.com/v1beta/models/${SENTIMENT_CONFIG.gemini.model}:generateContent`,
     {
       method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
+      headers: {
+        'x-goog-api-key': geminiApiKey,
+        'Content-Type': 'application/json',
+      },
+      signal: controller.signal,
       body: JSON.stringify({
         // ... existing body
       }),
     }
   );
+
+  clearTimeout(timeoutId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/batch-analyze-sentiment/route.ts` around lines 49 - 70, The batch
Gemini fetch call (the fetch(...) that assigns to response) needs timeout and
retry logic like the single-review route to avoid hanging or letting one
slow/failing call block the batch; wrap the fetch in an AbortController with a
configurable timeout (e.g., 5–10s) and add a retry loop with exponential backoff
(retry count 2–3) around the fetch call, and ensure failures for a single item
are caught and returned as an error result for that item rather than throwing
for the whole batch (update the code that constructs/handles response and any
per-item processing to tolerate and record individual timeouts/errors).
src/app/api/analyze-sentiment/route.ts-115-142 (1)

115-142: ⚠️ Potential issue | 🟠 Major

Missing timeout and API key in query parameter.

Similar to other Gemini API routes in this PR:

  1. The API key is passed in the URL query parameter instead of the recommended x-goog-api-key header.
  2. There's no timeout configured, which could cause the request to hang indefinitely if Gemini is slow.
🛡️ Proposed fix with timeout and header authentication
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 30000);
+
+    try {
       const response = await fetch(
-        `https://generativelanguage.googleapis.com/v1beta/models/${SENTIMENT_CONFIG.gemini.model}:generateContent?key=${geminiApiKey}`,
+        `https://generativelanguage.googleapis.com/v1beta/models/${SENTIMENT_CONFIG.gemini.model}:generateContent`,
         {
           method: 'POST',
           headers: {
+            'x-goog-api-key': geminiApiKey,
             'Content-Type': 'application/json',
           },
+          signal: controller.signal,
           body: JSON.stringify({
             // ... existing body
           }),
         }
       );
+      clearTimeout(timeoutId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/analyze-sentiment/route.ts` around lines 115 - 142, The fetch
call that constructs the Gemini request (in route.ts where response = await
fetch(...)) must send the API key in the x-goog-api-key header instead of as a
URL query param and must use a timeout via AbortController to avoid hanging;
update the request to remove key=${geminiApiKey} from the URL, add
headers['x-goog-api-key'] = geminiApiKey alongside 'Content-Type', and wrap the
fetch with an AbortController that calls abort() after a configurable timeout
(use SENTIMENT_CONFIG.gemini.timeout or a reasonable default) and ensure you
clear the timeout once the response arrives.
src/lib/sentiment-utils.ts-139-145 (1)

139-145: ⚠️ Potential issue | 🟠 Major

This loop performs serialized N+1 queries.

Each review does an awaited DB call in sequence, which can become a latency bottleneck for larger batches.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sentiment-utils.ts` around lines 139 - 145, The loop builds
reviewsWithoutSentiment by awaiting hasSentimentAnalysis(review.id) serially
causing N+1 latency; replace this with a concurrent approach—either
modify/replace hasSentimentAnalysis to accept an array of review IDs and perform
a single DB/batch query (preferred) or concurrently call hasSentimentAnalysis
for all reviews with Promise.all and then filter by the returned boolean array;
update the block that creates reviewsWithoutSentiment (the for loop and
hasSentimentAnalysis calls) and keep the existing check for review.comment &&
review.comment.length >= SENTIMENT_CONFIG.preprocessing.minCommentLength.
src/lib/__tests__/sentiment-analysis.test.ts-107-109 (1)

107-109: ⚠️ Potential issue | 🟠 Major

Valid comment case currently expects an impossible success path.

Line 107 sets reviewId: 'test-uuid', so a well-implemented API will return not found/validation error even with a valid comment. This makes the assertion misleading.

Also applies to: 115-118

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/__tests__/sentiment-analysis.test.ts` around lines 107 - 109, The
"Valid comment" test in src/lib/__tests__/sentiment-analysis.test.ts is
supplying a hardcoded reviewId ('test-uuid') which will cause the API to return
not-found/validation errors; update the test payload used in the "Valid comment"
case (and the similar block at the other failing case) to use a real review id
created earlier in the test setup or remove the reviewId field so the API can
accept the comment, ensuring the test asserts the successful sentiment response
from the handler (look for the test case name "Valid comment" and the object
with reviewId/comment/targetType to locate and fix).
src/migrations/sentiment_analysis.sql-135-136 (1)

135-136: ⚠️ Potential issue | 🟠 Major

aspect_sentiments aggregation is declared but never persisted.

aspect_avg is unused and the UPDATE statements never write aspect_sentiments, so that column stays stale/default.

Also applies to: 165-171, 216-222

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/migrations/sentiment_analysis.sql` around lines 135 - 136, The migration
computes an aggregated JSONB into the local variable aspect_avg (and similar
aggregates in the other blocks) but never persists it to the table, so
aspect_sentiments remains unchanged; modify the UPDATE statements that currently
reference aspect_avg to set the column (e.g., include "SET aspect_sentiments =
aspect_avg" or casted JSONB equivalent) after computing the aggregate, ensuring
the computed JSONB is written back for each relevant UPDATE/SELECT block that
declares aspect_avg and the other aggregates (the blocks around the declared
variable aspect_avg and the UPDATE statements that should write to
aspect_sentiments in those ranges).
setup_database.sql-10-16 (1)

10-16: ⚠️ Potential issue | 🟠 Major

Top-of-script cleanup is incomplete for a rerun path.

review_sentiments, related views, functions, and triggers are not dropped before dropping base tables, so rerunning this “clean migration” can fail on dependencies.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@setup_database.sql` around lines 10 - 16, Top-of-script cleanup fails to
remove dependent objects for reruns; before dropping base tables add explicit
DROP statements for the materialized/view/table "review_sentiments" and any
dependent views, functions and triggers (or use DROP ... IF EXISTS ... CASCADE)
so dependencies are removed first; specifically, call DROP VIEW/MATERIALIZED
VIEW IF EXISTS review_sentiments, DROP FUNCTION IF EXISTS <function names>(),
DROP TRIGGER IF EXISTS <trigger names> ON <table>, or simply DROP TABLE/VIEW ...
CASCADE before executing the existing DROP TABLE IF EXISTS
users/courses/professors/... sequence.
src/migrations/sentiment_analysis.sql-15-16 (1)

15-16: ⚠️ Potential issue | 🟠 Major

overall_sentiment type conflicts with decimal thresholds used elsewhere.

This schema stores integers only, but the feature logic/docs/tests use values like 4.5 and threshold boundaries at 4.5/3.5/2.5.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/migrations/sentiment_analysis.sql` around lines 15 - 16, The
overall_sentiment column is currently INTEGER which prevents storing decimal
values like 4.5 used elsewhere; change the column definition for
overall_sentiment to a numeric type that supports one decimal place (e.g.,
NUMERIC(2,1) NOT NULL) and keep or adjust its CHECK constraint to allow decimal
bounds (e.g., CHECK (overall_sentiment BETWEEN 1.0 AND 5.0)) so the DB schema
matches the feature/tests that use thresholds like 4.5/3.5/2.5; update the
definition in the migration where overall_sentiment is declared.
src/lib/__tests__/sentiment-analysis.test.ts-163-176 (1)

163-176: ⚠️ Potential issue | 🟠 Major

Database trigger test mutates persistent data without cleanup.

This upsert writes to a real review_sentiments row and can alter aggregate sentiment permanently in shared/staging DBs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/__tests__/sentiment-analysis.test.ts` around lines 163 - 176, The
test currently upserts into the real review_sentiments table via
supabaseAdmin.from('review_sentiments').upsert(...), which mutates persistent DB
state; change the test to avoid persistent writes by either wrapping the upsert
in a DB transaction and rolling it back, or by inserting then deleting the row
in a finally block. Concretely, capture the returned primary key from
supabaseAdmin.from('review_sentiments').upsert(...) (or .insert(...).select())
and ensure you call supabaseAdmin.from('review_sentiments').delete().eq('id',
returnedId) in a finally/afterEach to clean up, or replace the call with a
mocked supabaseAdmin client for this test so no real writes occur. Ensure
references to supabaseAdmin, upsert, and the review_sentiments table are updated
accordingly.
setup_database.sql-529-556 (1)

529-556: ⚠️ Potential issue | 🟠 Major

review_sentiments table must have RLS enabled to match the security model applied to all other tables.

All other tables (users, courses, professors, reviews, votes, flags) have RLS explicitly enabled at lines 346–352, but the review_sentiments table created at lines 529–556 lacks RLS enablement. The RLS ALTER statement at line 881 and policy statements at lines 882–887 are commented out.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@setup_database.sql` around lines 529 - 556, The review_sentiments table lacks
Row-Level Security; enable RLS for review_sentiments by adding an ALTER TABLE
... ENABLE ROW LEVEL SECURITY and then create the same per-role policies used
for the other tables (e.g., the policies that allow owners and privileged roles
to SELECT/INSERT/UPDATE/DELETE) so review_sentiments follows the same access
model as users, courses, professors, reviews, votes, and flags; ensure the
policy names and predicates mirror the existing ones applied to reviews (attach
policies covering owner-based access via review_id and any admin/system bypass
rules) and remove/commented-out placeholder policy statements so RLS is active
and enforced.
src/types/sentiment.ts-7-7 (1)

7-7: ⚠️ Potential issue | 🟠 Major

Type definition does not support decimal scoring used throughout the codebase.

SentimentScore restricts values to integers (1-5), but sentiment-config.ts defines thresholds at decimal boundaries (4.5, 3.5, 2.5, 1.5), and sentiment-utils.ts::getSentimentLabel() implements decimal-based classification logic. Mock data in tests and features consistently use decimal ratings (e.g., 4.5, 4.8, 3.8). Additionally, sentimentScoreToLabel() (lines 232-237) uses integer thresholds incompatible with getSentimentLabel().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/types/sentiment.ts` at line 7, SentimentScore is too narrow (only
integers) and doesn't match decimal thresholds in sentiment-config.ts or the
decimal logic in sentiment-utils.ts::getSentimentLabel(); change the
SentimentScore type from the integer union to a numeric type (e.g., export type
SentimentScore = number) and update any annotations/usages accordingly, and
unify classification logic by updating sentimentScoreToLabel to use the same
decimal boundaries as getSentimentLabel (or delegate to getSentimentLabel) so
thresholds (4.5, 3.5, 2.5, 1.5) are applied consistently across the codebase.
src/lib/sentiment-utils.ts-35-41 (1)

35-41: ⚠️ Potential issue | 🟠 Major

Server-side fallback to http://localhost:3000 is unsafe in production without NEXT_PUBLIC_BASE_URL configured.

When triggerSentimentAnalysis runs server-side (via getReviewsNeedingAnalysis batch processing) without NEXT_PUBLIC_BASE_URL set in the environment, it defaults to localhost and fails. The same pattern is repeated in src/pages/api/ratings/route.ts. Additionally, NEXT_PUBLIC_BASE_URL is missing from .env.example, leaving no guidance for developers to configure this required variable in production.

Either require the environment variable explicitly or add it to .env.example with clear documentation that it must be set for production deployments.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sentiment-utils.ts` around lines 35 - 41, The server-side code (e.g.,
in triggerSentimentAnalysis and getReviewsNeedingAnalysis where you build
baseUrl for fetch to /api/analyze-sentiment) currently falls back to
'http://localhost:3000' when NEXT_PUBLIC_BASE_URL is not set, which is unsafe;
change the logic so that on the server (typeof window === 'undefined') you do
not default to localhost but instead require NEXT_PUBLIC_BASE_URL (throw a clear
error or fail-fast with a descriptive message) and use that value for the fetch
URL, and also add NEXT_PUBLIC_BASE_URL with guidance to .env.example so
developers know to configure it for production deployments.
src/migrations/sentiment_analysis.sql-11-11 (1)

11-11: ⚠️ Potential issue | 🟠 Major

Qualify uuid_generate_v4() function calls to ensure proper resolution across migration contexts.

uuid_generate_v4() is unqualified here and in migration.sql. Since extensions are created in the extensions schema (not public), if the database search_path doesn't include that schema, function lookup will fail. Use extensions.uuid_generate_v4() instead, as already done for extensions.gen_random_bytes() and extensions.digest() in migration.sql line 502–506.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/migrations/sentiment_analysis.sql` at line 11, The DEFAULT expression for
the id column uses an unqualified uuid_generate_v4() which can fail if the
extensions schema isn't in search_path; update the id column DEFAULT to call
extensions.uuid_generate_v4() instead (replace the unqualified
uuid_generate_v4() in the id UUID PRIMARY KEY DEFAULT ... definition), matching
how extensions.gen_random_bytes() and extensions.digest() are referenced
elsewhere.
🟡 Minor comments (9)
docs/SETUP_GUIDE.md-10-12 (1)

10-12: ⚠️ Potential issue | 🟡 Minor

Add a language to the fenced block for markdown lint compliance.

Use something like ```text for the URL snippet.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/SETUP_GUIDE.md` around lines 10 - 12, Update the fenced code block that
currently contains the Supabase URL (the triple backtick block showing
"https://nljwzmdeznmreaoffgxj.supabase.co") to include a language identifier
(e.g., use ```text or ```url) so the Markdown linter recognizes the block
language; simply change the opening fence to include the language token while
keeping the URL content unchanged.
docs/SETUP_COMPLETE.md-27-30 (1)

27-30: ⚠️ Potential issue | 🟡 Minor

Use repository-relative start instructions instead of a machine-specific absolute path.

cd /home/akshat/Openlake/RateMyCourse won’t work for other contributors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/SETUP_COMPLETE.md` around lines 27 - 30, Replace the machine-specific
absolute path in the setup instructions with a repository-relative step so
contributors can follow them on any machine; update the block that shows
changing directory and starting the dev server so it instructs users to change
into the repository root (or run the commands from the cloned repo) and then run
the existing npm run dev command instead of cd
/home/akshat/Openlake/RateMyCourse.
docs/SETUP_COMPLETE.md-120-120 (1)

120-120: ⚠️ Potential issue | 🟡 Minor

Fix heading typo.

“You'reAll Set!” should be “You’re All Set!”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/SETUP_COMPLETE.md` at line 120, Update the heading text "You'reAll Set!"
to the corrected form "You’re All Set!" in the file (replace the contiguous text
in the heading line), ensuring the spacing between "You're" and "All" is fixed
and the apostrophe uses the curly typographic form as shown.
docs/SENTIMENT_QUICK_REFERENCE.md-3-5 (1)

3-5: ⚠️ Potential issue | 🟡 Minor

Update phase/status wording to match the current implementation state.

The document still frames sentiment work as upcoming (“Week 2/Week 3”), but this PR includes implementation. This can mislead setup/testing expectations.

Also applies to: 349-362

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/SENTIMENT_QUICK_REFERENCE.md` around lines 3 - 5, Update the status and
phase wording in SENTIMENT_QUICK_REFERENCE.md so it reflects that implementation
is included in this PR: change the header lines like "**Status**: Week 1 Design
Phase Complete ✅" and "**Next**: Week 2 Implementation Phase" (and any other
references to "Week 2/Week 3" in the sections around lines 349–362) to indicate
"Implementation in progress" or "Implementation complete" and update any
setup/testing notes to remove “upcoming” phrasing so readers know implementation
exists and how to run/tests the feature.
src/lib/verify-reviews.ts-10-34 (1)

10-34: ⚠️ Potential issue | 🟡 Minor

Missing error handling for null course and potential null comment access.

  1. If MAL100 is not found, course will be null, causing the subsequent queries and logs to fail silently or produce confusing output.
  2. Line 33 calls r.comment.substring(0, 100) but the reviews query doesn't filter out null comments, which will throw a runtime error.
🐛 Proposed fix
   const { data: course } = await supabase
     .from('courses')
     .select('id, code, title, review_count, overall_rating, difficulty_rating, workload_rating')
     .eq('code', 'MAL100')
     .single();

+  if (!course) {
+    console.error('❌ Course MAL100 not found');
+    return;
+  }
+
   console.log('\n📊 MAL100 Course Stats:');
   console.log('=======================');
-  console.log('Course:', course?.code, '-', course?.title);
-  console.log('Reviews:', course?.review_count);
-  console.log('Overall Rating:', course?.overall_rating, '⭐');
-  console.log('Difficulty:', course?.difficulty_rating, '/5');
-  console.log('Workload:', course?.workload_rating, '/5');
+  console.log('Course:', course.code, '-', course.title);
+  console.log('Reviews:', course.review_count);
+  console.log('Overall Rating:', course.overall_rating, '⭐');
+  console.log('Difficulty:', course.difficulty_rating, '/5');
+  console.log('Workload:', course.workload_rating, '/5');
   
   const { data: reviews } = await supabase
     .from('reviews')
     .select('rating_value, comment')
-    .eq('target_id', course?.id)
-    .eq('target_type', 'course');
+    .eq('target_id', course.id)
+    .eq('target_type', 'course')
+    .not('comment', 'is', null);
   
   console.log('\n📝 Sample Reviews:');
   reviews?.slice(0, 3).forEach((r, i) => {
     console.log(`\n${i + 1}. Rating: ${r.rating_value}⭐`);
-    console.log(`   "${r.comment.substring(0, 100)}..."`);
+    console.log(`   "${r.comment?.substring(0, 100) ?? '(no comment)'}..."`);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/verify-reviews.ts` around lines 10 - 34, The code assumes course and
review comments are always present; add null checks and early exits: after
fetching course (variable course) verify it exists and if not log a clear
message and return/throw before using course.id or logging course fields; when
querying reviews only call .eq('target_id', course.id) if course is non-null.
For printing fields like course.review_count and ratings use safe access (or
fallback values) to avoid undefined output. For sample reviews, ensure reviews
is an array and guard comment access by using r.comment?.substring(0,100) or
defaulting r.comment to '' before substring to avoid runtime errors when comment
is null.
src/lib/sentiment-utils.ts-240-250 (1)

240-250: ⚠️ Potential issue | 🟡 Minor

Emotion color mapping is incomplete for configured emotions.

Values like inspired, motivated, indifferent, uncertain, calm, confused, and stressed all fall back to gray.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sentiment-utils.ts` around lines 240 - 250, The emotionColors mapping
in sentiment-utils.ts is missing entries for several configured emotions causing
many values to fall back to gray; update the emotionColors Record<string,string>
(and any related mapping logic used by the function that returns
emotionColors[emotion.toLowerCase()]) to include keys for inspired, motivated,
indifferent, uncertain, calm, confused, stressed (and any other configured
emotions) with appropriate color values, and ensure the lookup still uses
emotion.toLowerCase() so the existing return statement (return
emotionColors[emotion.toLowerCase()] || 'gray') works as intended.
docs/SENTIMENT_BACKEND_IMPLEMENTATION.md-111-113 (1)

111-113: ⚠️ Potential issue | 🟡 Minor

Retry description and formula are inconsistent.

Line 112 shows 1000ms * retry_count (linear backoff), but the text says exponential backoff.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/SENTIMENT_BACKEND_IMPLEMENTATION.md` around lines 111 - 113, The retry
description is inconsistent: update the documentation so the backoff formula
matches the claimed exponential behavior; either change the formula line
currently reading "Delay: 1000ms * retry_count" to an exponential formula such
as "Delay: 1000ms * (2 ** (retry_count - 1))" or change the wording to "linear
backoff" to match the current formula—ensure the line under the retry policy and
the phrase "exponential backoff" are consistent.
docs/SENTIMENT_BACKEND_IMPLEMENTATION.md-214-219 (1)

214-219: ⚠️ Potential issue | 🟡 Minor

Fix invalid JSON in request example.

"targetType": "course" | "professor" is TypeScript-union syntax, not valid JSON, so copy/paste integrations will fail.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/SENTIMENT_BACKEND_IMPLEMENTATION.md` around lines 214 - 219, The example
JSON uses TypeScript union syntax for the "targetType" field which is invalid
JSON; update the example in SENTIMENT_BACKEND_IMPLEMENTATION.md to use a
concrete string value for "targetType" (e.g., "course" or "professor") or
provide two separate valid JSON examples showing each allowed value so
copy/paste integrations work; ensure the field name "targetType" remains
unchanged and only the value format is corrected.
src/types/sentiment.ts-123-130 (1)

123-130: ⚠️ Potential issue | 🟡 Minor

Remove the unused ratings field from AnalyzeSentimentRequest.

The ratings field is declared as mandatory in this type, but all callers omit it when sending requests (see triggerSentimentAnalysis in src/lib/sentiment-utils.ts line 46–50). The field is never used in the sentiment analysis logic. Either make it optional or remove it to align the type contract with actual usage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/types/sentiment.ts` around lines 123 - 130, The AnalyzeSentimentRequest
type currently declares a mandatory ratings field that is never populated or
used; remove the ratings property from the AnalyzeSentimentRequest type
declaration to match actual usage (instead of making it optional), then update
any references to ratings (if any) and run the TypeScript build/typecheck to
ensure no remaining usages break (check triggerSentimentAnalysis in
src/lib/sentiment-utils.ts as a caller to confirm compatibility).
🧹 Nitpick comments (6)
src/app/api/extract-themes/route.ts (1)

6-6: Unused courseCode parameter.

The courseCode is destructured from the request body but never used in the function. Consider removing it from the destructuring or using it in logging/the prompt for context.

♻️ Suggested fix
-    const { courseId, courseCode } = await request.json();
+    const { courseId } = await request.json();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/extract-themes/route.ts` at line 6, The request body destructures
courseId and courseCode in the route handler (const { courseId, courseCode } =
await request.json();) but courseCode is unused; either remove courseCode from
the destructuring to avoid dead variable warnings or wire courseCode into the
logic (for example include it in the processLogger calls or incorporate it into
the prompt/context construction where courseId is used). Update the route
handler (the function that calls request.json()) accordingly so only used
parameters are kept or consumed.
src/app/api/batch-analyze-sentiment/route.ts (1)

14-17: Weak return type reduces type safety.

analyzeWithGemini returns Promise<any>, which loses type information. Consider defining a proper return type interface for better maintainability and IDE support.

♻️ Suggested fix
+interface GeminiSentimentResult {
+  overallSentiment: number;
+  overallConfidence: number;
+  aspectSentiments: Record<string, { score: number; confidence: number }>;
+  primaryEmotion: string | null;
+  emotionIntensity: number | null;
+  rawResponse: any;
+}

 async function analyzeWithGemini(
   comment: string,
   targetType: 'course' | 'professor'
-): Promise<any> {
+): Promise<GeminiSentimentResult> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/batch-analyze-sentiment/route.ts` around lines 14 - 17, The
function analyzeWithGemini currently returns Promise<any>, losing type safety;
define a clear return interface (e.g., SentimentAnalysisResult with fields like
sentimentLabel, score, details, targetType) and replace Promise<any> with
Promise<SentimentAnalysisResult> in the analyzeWithGemini signature, update the
function body to construct and return that typed object, and update any callers
(e.g., functions that invoke analyzeWithGemini) to use the new type for proper
typing and IDE autocompletion.
src/app/api/analyze-sentiment/route.ts (1)

191-196: Backoff is linear, not exponential.

The retry delay uses retryDelay * retries, which produces linear backoff (e.g., 1s, 2s, 3s). For exponential backoff, use retryDelay * Math.pow(2, retries) (e.g., 1s, 2s, 4s).

♻️ Suggested fix for exponential backoff
       if (retries < SENTIMENT_CONFIG.gemini.retryAttempts) {
         // Wait before retrying
         await new Promise(resolve => 
-          setTimeout(resolve, SENTIMENT_CONFIG.gemini.retryDelay * retries)
+          setTimeout(resolve, SENTIMENT_CONFIG.gemini.retryDelay * Math.pow(2, retries))
         );
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/analyze-sentiment/route.ts` around lines 191 - 196, The retry
wait currently multiplies SENTIMENT_CONFIG.gemini.retryDelay by retries,
producing linear backoff; update the delay calculation in the retry block inside
src/app/api/analyze-sentiment/route.ts to use exponential backoff (e.g.,
multiply retryDelay by Math.pow(2, retries) or 2 ** retries) so the wait becomes
retryDelay * 2^retries; keep the existing condition (if (retries <
SENTIMENT_CONFIG.gemini.retryAttempts)) and the Promise/setTimeout structure but
replace the linear multiplier with the exponential one using the existing
retries and SENTIMENT_CONFIG.gemini.retryDelay identifiers.
src/lib/add-dummy-reviews.ts (1)

103-105: Fragile UUID generation pattern.

The dummy UUID pattern 00000000-0000-0000-0000-00000000000${i} only produces valid UUIDs for single-digit indices (0-9). If more reviews are added to the array, the UUID format would break (e.g., index 10 would produce ...00000000010 which is 13 characters instead of 12).

♻️ Suggested fix using padded indices
-          anonymous_id: `00000000-0000-0000-0000-00000000000${i}`, // Dummy UUIDs
+          anonymous_id: `00000000-0000-0000-0000-${String(i).padStart(12, '0')}`, // Dummy UUIDs

Or use crypto.randomUUID() for proper UUIDs:

anonymous_id: crypto.randomUUID(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/add-dummy-reviews.ts` around lines 103 - 105, Replace the fragile
string-based anonymous_id generation inside the .insert({ ... }) call with a
proper UUID generator: use crypto.randomUUID() (or import randomUUID from
'crypto' and call randomUUID()) for each dummy review, e.g. replace the template
`00000000-...${i}` with crypto.randomUUID(); alternatively, if deterministic IDs
are required, generate a fixed-width padded index (i.toString().padStart(12,
'0')) and insert that as anonymous_id. Update the anonymous_id expression where
.insert({ anonymous_id: ... , target_id: course.id }) is used so all created
dummy IDs remain valid UUIDs or consistently padded identifiers.
src/lib/sentiment-config.ts (2)

247-251: AspectKey is course-only but named as if it were global.

This type excludes professor aspects and can create incorrect assumptions in shared utilities.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sentiment-config.ts` around lines 247 - 251, The AspectKey type is
incorrectly scoped to SENTIMENT_CONFIG.aspects.course (so it excludes professor
aspects); change its definition to derive keys from the full aspects object
(e.g., use keyof typeof SENTIMENT_CONFIG.aspects or otherwise union the keys
from all aspect groups) so utilities using AspectKey reflect all aspect kinds;
update any usages of AspectKey if they assumed course-only semantics.

6-251: Add as const to SENTIMENT_CONFIG to enforce literal types for emotion values.

Without as const, the EmotionValue type resolves to string instead of a literal union, losing type strictness. While this doesn't currently affect the codebase (the type is exported but unused), adding as const improves type safety and would prevent issues if the type is consumed in the future.

Suggested fix
-export const SENTIMENT_CONFIG = {
+export const SENTIMENT_CONFIG = {
  ...
-};
+} as const;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sentiment-config.ts` around lines 6 - 251, The SENTIMENT_CONFIG
object is currently mutable so EmotionValue collapses to string; fix by making
SENTIMENT_CONFIG a readonly literal by adding "as const" to its declaration
(i.e., export const SENTIMENT_CONFIG = { ... } as const). This will preserve
literal types for nested emotion values and tighten the EmotionValue type; keep
the existing exported shortcuts (GEMINI_CONFIG, PREPROCESSING_CONFIG, etc.)
as-is since they will inherit readonly literal types from SENTIMENT_CONFIG.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7200de7c-399f-46a4-8508-98e5c96004ae

📥 Commits

Reviewing files that changed from the base of the PR and between ea1f700 and 2a7982c.

📒 Files selected for processing (26)
  • docs/SENTIMENT_ANALYSIS_DESIGN.md
  • docs/SENTIMENT_BACKEND_IMPLEMENTATION.md
  • docs/SENTIMENT_QUICK_REFERENCE.md
  • docs/SETUP_COMPLETE.md
  • docs/SETUP_GUIDE.md
  • docs/WEEK1_CHECKLIST.md
  • setup.sh
  • setup_database.sql
  • src/app/api/analyze-sentiment/route.ts
  • src/app/api/batch-analyze-sentiment/route.ts
  • src/app/api/extract-themes/route.ts
  • src/app/api/generate-summary/route.ts
  • src/components/courses/AddReviewButton.tsx
  • src/components/courses/course_page/CourseKeyThemes.tsx
  • src/components/courses/course_page/CourseSummary.tsx
  • src/components/professors/AddReviewButtonProfessor.tsx
  • src/lib/__tests__/sentiment-analysis.test.ts
  • src/lib/add-dummy-reviews.ts
  • src/lib/check-db.ts
  • src/lib/sentiment-config.ts
  • src/lib/sentiment-utils.ts
  • src/lib/verify-reviews.ts
  • src/migrations/migration.sql
  • src/migrations/sentiment_analysis.sql
  • src/pages/api/ratings/route.ts
  • src/types/sentiment.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/WEEK1_CHECKLIST.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/courses/course_page/CourseKeyThemes.tsx

Comment on lines +6 to +7
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions;
CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA extensions;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

fd -type f -name "setup_database.sql"

Repository: OpenLake/RateMyCourse

Length of output: 235


🏁 Script executed:

fd -t f "setup_database.sql"

Repository: OpenLake/RateMyCourse

Length of output: 83


🏁 Script executed:

cat -n setup_database.sql

Repository: OpenLake/RateMyCourse

Length of output: 36720


🏁 Script executed:

rg -n "search_path" setup_database.sql

Repository: OpenLake/RateMyCourse

Length of output: 47


Unqualified uuid_generate_v4() calls in DEFAULT expressions may fail in some environments.

Extensions are installed in the extensions schema (lines 6-7), but DEFAULT clauses at lines 20, 22, 31, 46, 72, 96, 108, and 530 call unqualified uuid_generate_v4(). This depends on search_path configuration and will cause INSERT failures if the extensions schema is not in the search_path. The code itself demonstrates awareness of this issue by explicitly qualifying other extension functions (extensions.gen_random_bytes() and extensions.digest() at lines 502, 506), making the inconsistency a clear oversight.

Qualify all uuid_generate_v4() calls as extensions.uuid_generate_v4() to ensure reliable operation across environments.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@setup_database.sql` around lines 6 - 7, DEFAULT expressions that call
uuid_generate_v4() are unqualified and will fail if the extensions schema isn't
in the search_path; update every unqualified uuid_generate_v4() call used in
DEFAULT clauses (e.g., the DEFAULT uuid_generate_v4() occurrences referenced in
the PR) to be fully qualified as extensions.uuid_generate_v4() so they
consistently reference the installed extension, mirroring how
extensions.gen_random_bytes() and extensions.digest() are already qualified.

Comment on lines +395 to +410
CREATE POLICY course_update ON courses
FOR UPDATE USING (true) WITH CHECK (true);

CREATE POLICY course_delete ON courses
FOR DELETE USING (is_admin());

-- Professor policies (public read, admin write, but allow trigger updates)
CREATE POLICY professor_select ON professors
FOR SELECT USING (true);

CREATE POLICY professor_insert ON professors
FOR INSERT WITH CHECK (is_admin());

CREATE POLICY professor_update ON professors
FOR UPDATE USING (true) WITH CHECK (true);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

git ls-files | grep -i setup

Repository: OpenLake/RateMyCourse

Length of output: 135


🏁 Script executed:

# Get the full setup_database.sql file
wc -l setup_database.sql && head -100 setup_database.sql

Repository: OpenLake/RateMyCourse

Length of output: 3728


🏁 Script executed:

# Look for the broader context around the policies - check for role definitions
cat setup_database.sql | grep -i -E "(CREATE ROLE|GRANT|CREATE POLICY)" | head -50

Repository: OpenLake/RateMyCourse

Length of output: 1323


🏁 Script executed:

# Search for is_admin function definition
rg "is_admin" setup_database.sql -A 5

Repository: OpenLake/RateMyCourse

Length of output: 2211


🏁 Script executed:

sed -n '395,410p' setup_database.sql

Repository: OpenLake/RateMyCourse

Length of output: 551


🏁 Script executed:

# Also get surrounding context for course_update and professor_update
sed -n '390,420p' setup_database.sql

Repository: OpenLake/RateMyCourse

Length of output: 965


🏁 Script executed:

# Look for any triggers that might validate/restrict updates to courses or professors
rg "CREATE TRIGGER|CREATE OR REPLACE TRIGGER" setup_database.sql -A 10 | head -80

Repository: OpenLake/RateMyCourse

Length of output: 2921


course_update and professor_update policies allow unrestricted updates by any authenticated user.

The policies use USING (true) WITH CHECK (true), enabling any authenticated user to directly modify course and professor records. This contradicts the authorization model where INSERT and DELETE are restricted to admins. While the comment notes these policies "allow trigger updates," triggers execute independently through their own security context (SECURITY DEFINER functions) and do not justify open direct access. Direct user updates should still be restricted to admins via USING (is_admin()) WITH CHECK (is_admin()), consistent with insert/delete policies and other sensitive operations like flag_update.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@setup_database.sql` around lines 395 - 410, The course_update and
professor_update policies currently allow any authenticated user to update
courses and professors (USING (true) WITH CHECK (true)); change both policies to
restrict direct updates to admins by using USING (is_admin()) WITH CHECK
(is_admin()) for the course_update and professor_update policies on the courses
and professors tables respectively so updates match INSERT/DELETE restrictions
(triggers can still run via SECURITY DEFINER functions).

Comment on lines +752 to +763
DROP TRIGGER IF EXISTS trigger_update_course_sentiment ON review_sentiments;
CREATE TRIGGER trigger_update_course_sentiment
AFTER INSERT OR UPDATE ON review_sentiments
FOR EACH ROW
EXECUTE FUNCTION update_course_sentiment_aggregates();

-- Trigger to update professor sentiment when review_sentiment is inserted/updated
DROP TRIGGER IF EXISTS trigger_update_professor_sentiment ON review_sentiments;
CREATE TRIGGER trigger_update_professor_sentiment
AFTER INSERT OR UPDATE ON review_sentiments
FOR EACH ROW
EXECUTE FUNCTION update_professor_sentiment_aggregates();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Sentiment aggregate triggers still miss DELETE events.

Deleting sentiment rows (including cascades from deleted reviews) can leave aggregate sentiment data stale.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@setup_database.sql` around lines 752 - 763, The triggers
trigger_update_course_sentiment and trigger_update_professor_sentiment on table
review_sentiments currently fire AFTER INSERT OR UPDATE only; modify their
CREATE TRIGGER statements to also fire ON DELETE (AFTER INSERT OR UPDATE OR
DELETE) so that the functions update_course_sentiment_aggregates() and
update_professor_sentiment_aggregates() run on deletions (including cascades)
and keep aggregate sentiment data consistent for the review_sentiments table.

Comment on lines +13 to +21
const { data: professors, error: profError } = await supabase
.from('professors')
.select('id', { count: 'exact', head: true });

if (profError) {
console.error('❌ Error checking professors:', profError.message);
} else {
console.log(`✅ Professors: ${professors?.length || 0} records`);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

supabase-js select head: true count: 'exact' return values data count

💡 Result:

In supabase-js, .select() returns an object like:

  • data: the rows (array) unless you set head: true
  • count: the total number of rows matched only if you pass count: 'exact' | 'planned' | 'estimated'
  • error: any error

With head: true

Supabase sends a HEAD request, so no rows are returned:

const { data, count, error } = await supabase
  .from('characters')
  .select('*', { head: true, count: 'exact' })

// data === null
// count === (number of matching rows)

This is expected: head returns no data, so data is null; the row total is in count. [1][2]

With head: false (default)

You can get both rows and count:

const { data, count, error } = await supabase
  .from('characters')
  .select('*', { count: 'exact' })

// data === Row[]
// count === total matching rows

Note: by default Supabase limits returned rows to 1,000 per request (so data.length may be ≤ 1000 even when count is larger). [1]

Sources:
[1] Supabase docs: JavaScript select() (examples incl. { count: 'exact', head: true }, and 1,000 row default)
[2] Supabase GitHub discussion: “Count comes back in count. Head returns no data so null.”


Professor count will always display zero with head: true.

When using .select() with head: true, the response's data field is null (no rows returned). Line 20 attempts to access professors?.length, which evaluates to undefined, and the fallback || 0 always results in displaying "0 records" regardless of actual row count.

Use the count field from the response instead:

Fix
-  const { data: professors, error: profError } = await supabase
+  const { count: professorCount, error: profError } = await supabase
     .from('professors')
     .select('id', { count: 'exact', head: true });
   
   if (profError) {
     console.error('❌ Error checking professors:', profError.message);
   } else {
-    console.log(`✅ Professors: ${professors?.length || 0} records`);
+    console.log(`✅ Professors: ${professorCount ?? 0} records`);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/check-db.ts` around lines 13 - 21, The query using
supabase.from('professors').select(..., { head: true }) returns no rows in data,
so reading professors?.length is incorrect; update the handling to use the
response's count value instead (the destructured variables are currently data:
professors and error: profError) — when profError is falsy, log the count
(fallback to 0) from the response's count property rather than professors.length
and ensure the code handles a null count safely.

Comment on lines +129 to +145
CREATE OR REPLACE FUNCTION update_course_sentiment_aggregates()
RETURNS TRIGGER AS $$
DECLARE
target_course_id UUID;
avg_sentiment NUMERIC;
sentiment_dist JSONB;
aspect_avg JSONB;
BEGIN
-- Get the target course ID from the review
SELECT r.target_id INTO target_course_id
FROM reviews r
WHERE r.id = NEW.review_id AND r.target_type = 'course';

-- Only proceed if this is a course review
IF target_course_id IS NULL THEN
RETURN NEW;
END IF;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Aggregates are never recomputed on DELETE.

If a review or sentiment row is deleted, courses/professors sentiment fields can remain stale because triggers fire only on INSERT/UPDATE.

Also applies to: 181-197, 233-244

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/migrations/sentiment_analysis.sql` around lines 129 - 145, The trigger
function update_course_sentiment_aggregates only reads NEW.review_id so it never
recomputes aggregates on DELETE; update the function to handle TG_OP for
'DELETE' (and ensure it also covers 'UPDATE'/'INSERT') by using OLD.review_id
when TG_OP = 'DELETE' and by selecting and recomputing avg_sentiment,
sentiment_dist and aspect_avg after the row change, then update the courses
table; apply the same change to the analogous professor trigger function (e.g.,
update_professor_sentiment_aggregates) and any other sentiment-aggregate trigger
functions so deletes use OLD and trigger logic branches for TG_OP = 'DELETE' to
keep aggregates consistent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FOSSOVERFLOW-25] Tag Extraction

2 participants